リッジラインプロット#

リッジラインプロットRidgeline Plot ) とは、量的変数に対して、分布を滑らかな 曲線 で表現した可視化手法です。 密度プロット(あるいはバイオリンプロットを90度回転したもの)を縦に並べた、文字通り山脈の稜線のような見た目をしています。 特に動的に変化する分布の推移を表現する際に強力です。

アニメ作品の年代別の合計話数の分布を表すリッジラインプロットを例に説明します。 リッジラインプロットは、一つ目の 位置 スケール(上図「位置①」)で質的変数(上図「年代」)の水準(上図「2000」年代)を指定し、 それと直交する二つ目の 位置 スケール(上図「位置②」)で量的変数(上図「各話数」)の数量(上図「26」話付近)を指定し、 さらにそれと直交する三つ目の 位置 スケール(上図「位置③」)を端点とする曲線で、その確率密度分布を表現します。

バイオリンプロットと同様、複数の分布を可視化して比較したい質的変数を グループ化変数 、分布の可視化対象となる量的変数を 応答変数 と呼ぶことがあります。 上図の例では、グループ化変数は「年代」であり、応答変数は「各話数」です。 バイオリンプロットを縦横反転させた形状をしているため、グループ化変数はY軸に配置され、応答変数はX軸に配置されます。

リッジラインプロットでは、基本的にグループ化変数として 順序尺度 を採用します。 上図の例では「年代」という順序尺度に応じた「各話数」の推移を表現しています。

Plotlyでは直接リッジラインプロットを描画する関数がないため、 バイオリンプロットを応用して作図します.

# plotly.expressモジュールをpxという名前でインポート
# 簡単にインタラクティブな図を作成するためのモジュール
import plotly.express as px

# px.violin関数を応用して、リッジラインプロットを作成
# 'df'データフレームの'col_x'カラムをx軸、'col_y'カラムをy軸にしてプロットを表示
# orientation="h"で水平方向のバイオリンプロットを作成
# points=Falseで各データポイントをプロットに表示しない
# 作成した図は'fig'という変数に保存される
fig = px.violin(df, x="col_x", y="col_y", orientation="h", points=False)

# fig.update_tracesメソッドでバイオリンプロットのスタイルを更新
# side='positive'でバイオリンプロットを正の方向(上側)のみに表示
# scalemode="count"とすることで、実データ数に応じてバイオリンの幅を調整
fig.update_traces(side="positive", scalemode="count")

基本的にはバイオリンプロットの作図方法を踏襲しますが、以下が異なる点にご注意ください。

  • xyの関係が逆:縦横反転するため、分布を見たい列をxで指定することにご注意ください。

  • orientation="h":縦横反転を指定します。

  • side="positive":縦軸の上側のみ描画するよう指定します。

初期設定#

以降では、マンガ・アニメ・ゲームデータを可視化するための初期設定を行います。 なお、紙幅の都合のため、書籍版と一部構成が異なることにご注意ください。

Import#

必要なライブラリをImportします。

Hide code cell content
# warningsモジュールのインポート
import warnings

# データ解析や機械学習のライブラリ使用時の警告を非表示にする目的で警告を無視
# 本書の文脈では、可視化の学習に議論を集中させるために選択した
# ただし、学習以外の場面で、警告を無視する設定は推奨しない
warnings.filterwarnings("ignore")
Hide code cell content
# pathlibモジュールのインポート
# ファイルシステムのパスを扱う
from pathlib import Path

# typingモジュールからの型ヒント関連のインポート
# 関数やクラスの引数・返り値の型を注釈するためのツール
from typing import Any, Dict, List, Optional, Union

# numpy:数値計算ライブラリのインポート
# npという名前で参照可能
import numpy as np

# pandas:データ解析ライブラリのインポート
# pdという名前で参照可能
import pandas as pd

# plotly.expressのインポート
# インタラクティブなグラフ作成のライブラリ
# pxという名前で参照可能
import plotly.express as px

# plotly.graph_objectsからFigureクラスのインポート
# 型ヒントの利用を主目的とする
from plotly.graph_objects import Figure

なお、型ヒントについてはこちらを参照ください。

定数#

本Notebookで用いる定数を定義します。 なお、Pythonにおける定数の扱いについては、こちらを参照ください。

Hide code cell content
# マンガデータ保存ディレクトリのパス
DIR_CM = Path("../../data/cm/input")
# アニメデータ保存ディレクトリのパス
DIR_AN = Path("../../data/an/input")
# ゲームデータ保存ディレクトリのパス
DIR_GM = Path("../../data/gm/input")

# マンガデータの分析結果の出力先ディレクトリのパス
DIR_OUT_CM = DIR_CM.parent / "output" / Path.cwd().parts[-1] / "ridge"
# アニメデータの分析結果の出力先ディレクトリのパス
DIR_OUT_AN = DIR_AN.parent / "output" / Path.cwd().parts[-1] / "ridge"
# ゲームデータの分析結果の出力先ディレクトリのパス
DIR_OUT_GM = DIR_GM.parent / "output" / Path.cwd().parts[-1] / "ridge"
Hide code cell content
# 読み込み対象ファイル名の定義

# Comic Episode関連のファイル名
FN_CE = "cm_ce.csv"

# Anime Episode関連のファイル名
FN_AE = "an_ae.csv"

# PacKaGeとPlatForm関連のファイル名
FN_PKG_PF = "gm_pkg_pf.csv"
Hide code cell content
# 可視化に関する設定値の定義

# 「年代」の集計単位
UNIT_YEARS = 10

# 可視化対象とするマンガ作品の条件として、最小の各話数を定義
MIN_N_CE = 5
Hide code cell content
# サンプリング時のシード値
RAND = 0
Hide code cell content
# plotlyの描画設定の定義

# plotlyのグラフ描画用レンダラーの定義
# Jupyter Notebook環境のグラフ表示に適切なものを選択
RENDERER = "plotly_mimetype+notebook"

関数#

以下では、本Notebookで用いる関数を定義します。

Hide code cell content
def show_fig(fig: Figure) -> None:
    """
    所定のレンダラーを用いてplotlyの図を表示する関数
    Jupyter Bookなどの環境での正確な表示を目的とする

    Parameters:
    - fig (Figure): 表示対象のplotly図

    Returns:
    - None
    """

    # 図の周囲の余白を設定
    # t: 上余白
    # l: 左余白
    # r: 右余白
    # b: 下余白
    fig.update_layout(margin=dict(t=25, l=25, r=25, b=25))

    # 所定のレンダラーで図を表示
    fig.show(renderer=RENDERER)
Hide code cell content
def add_years_to_df(
    df: pd.DataFrame, unit_years: int = UNIT_YEARS, col_date: str = "date"
) -> pd.DataFrame:
    """
    データフレームにunit_years単位で区切った年数を示す新しい列を追加

    Parameters
    ----------
    df : pd.DataFrame
        入力データフレーム
    unit_years : int, optional
        年数を区切る単位、デフォルトはUNIT_YEARS
    col_date : str, optional
        日付を含むカラム名、デフォルトは "date"

    Returns
    -------
    pd.DataFrame
        新しい列が追加されたデータフレーム
    """

    # 入力データフレームをコピー
    df_new = df.copy()

    # unit_years単位で年数を区切り、新しい列として追加
    df_new["years"] = (
        pd.to_datetime(df_new[col_date]).dt.year // unit_years * unit_years
    )

    # 'years'列のデータ型を文字列に変更
    df_new["years"] = df_new["years"].astype(str)

    return df_new
Hide code cell content
def format_cols(df: pd.DataFrame, cols_rename: Dict[str, str]) -> pd.DataFrame:
    """
    指定されたカラムのみをデータフレームから抽出し、カラム名をリネームする関数

    Parameters
    ----------
    df : pd.DataFrame
        入力データフレーム
    cols_rename : Dict[str, str]
        リネームしたいカラム名のマッピング(元のカラム名: 新しいカラム名)

    Returns
    -------
    pd.DataFrame
        カラムが抽出・リネームされたデータフレーム
    """

    # 指定されたカラムのみを抽出し、リネーム
    df = df[cols_rename.keys()].rename(columns=cols_rename)

    return df
Hide code cell content
def save_df_to_csv(df: pd.DataFrame, dir_save: Path, fn_save: str) -> None:
    """
    DataFrameをCSVファイルとして指定されたディレクトリに保存する関数

    Parameters
    ----------
    df : pd.DataFrame
        保存対象となるDataFrame
    dir_save : Path
        出力先ディレクトリのパス
    fn_save : str
        保存するCSVファイルの名前(拡張子は含めない)
    """
    # 出力先ディレクトリが存在しない場合は作成
    dir_save.mkdir(parents=True, exist_ok=True)

    # 出力先のパスを作成
    p_save = dir_save / f"{fn_save}.csv"

    # DataFrameをCSVファイルとして保存する
    df.to_csv(p_save, index=False, encoding="utf-8-sig")

    # 保存完了のメッセージを表示する
    print(f"DataFrame is saved as '{p_save}'.")

可視化例#

マンガデータ#

マンガ各話のページ数の推移を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ce = pd.read_csv(DIR_CM / FN_CE)
Hide code cell content
# マンガ雑誌の掲載データから、特定の条件を満たす作品のみを選択して集計を行う

# 各マンガ作品(ccid)に対して、掲載された回数(ceidのユニーク数)をカウント
df_tmp = df_ce.groupby("ccid")["ceid"].nunique().reset_index(name="n_ce")

# 掲載された回数がMIN_N_CE以上のマンガ作品のIDをリストとして取得
ccids = df_tmp[df_tmp["n_ce"] >= MIN_N_CE]["ccid"].unique().tolist()

# 上で取得したマンガ作品IDのみを含むデータをdf_cmに格納
df_cm = df_ce[df_ce["ccid"].isin(ccids)].reset_index(drop=True)

# df_ceにyears列を追加
df_cm = add_years_to_df(df_ce)

# 列名をわかりやすいものに変更
cols_cm = {
    "mcname": "マンガ雑誌名",
    "years": "年代",
    "pages": "一話あたりのページ数",
    "ceid": "ceid",
}
df_cm = format_cols(df_cm, cols_cm)

事前に、グループ化変数(年代)ごとのサンプルサイズを確認しておきましょう。 極端にサンプルが少ない水準は、この時点で可視化対象から外すことを検討します。

Hide code cell content
# 事前にグループ化変数間でサンプルサイズに違いがないか確認
df_cm.groupby("年代")["ceid"].nunique().reset_index()
年代 ceid
0 1970 25833
1 1980 33765
2 1990 40432
3 2000 44792
4 2010 35254

どの年代も十分なサンプルサイズがあることから、全ての年代を可視化対象とします。 ただし、1970年代は比較的少なく、2000年代の半分以下のサンプルサイズしかないことは頭の片隅に置いておきましょう。

Hide code cell content
# 可視化対象のDataFrameを確認
df_cm.head()
マンガ雑誌名 年代 一話あたりのページ数 ceid
0 週刊少年マガジン 2010 22.0 CE00000
1 週刊少年マガジン 2010 18.0 CE00001
2 週刊少年マガジン 2010 18.0 CE00002
3 週刊少年マガジン 2010 20.0 CE00003
4 週刊少年マガジン 2010 20.0 CE00004
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_cm, DIR_OUT_CM, "cm")
DataFrame is saved as '../../data/cm/output/06/ridge/cm.csv'.
Hide code cell source
# リッジラインプロットを作成し、年代ごとの各話ページ数を可視化
# この際、データセットの半分をランダムにサンプリングし、データサイズを圧縮
# orientation="h"と指定することで、縦横転置
# points=Falseによって、サンプルを表示しないよう設定
fig = px.violin(
    df_cm.sample(frac=0.5, random_state=RAND).sort_values("年代"),
    y="年代",
    x="一話あたりのページ数",
    orientation="h",
    points=False,
)

# scalemode="count"とし、グループ間のサンプルサイズをスケールに反映
# side="positive"とし、上部にのみ密度分布が表示されるよう設定
fig.update_traces(scalemode="count", side="positive")

# 作成した図を表示
show_fig(fig)

上図は、マンガ作品の各話数の推移を表現したリッジラインプロットです。 年代ごとに各話数の分布は大きく変わらないように見えます。

ここまで、scalemode="count"とすることで、年代ごとのサンプルサイズに応じて確率密度関数のスケールを自動調整していました。 つまり、たくさんのサンプルを含む年代では、同じ確率密度の他の年代と比較し、「山」が高くなっていました。

しかし、この設定では上図のように分布のスケールが全体的に小さくなり、比較しづらくなってしまうことがあります。 このような場合は、widthパラメータを調整してY軸方向のスケールを 手動で 調整しましょう。 ただし、widthパラメータを設定すると、scalemode="count"scalemode="width"に上書きされ、年代間のサンプルサイズを考慮しないスケーリングとなることに注意して下さい。

Hide code cell source
# widthを手動で再設定
fig.update_traces(width=2)

# リッジラインプロットを再表示
show_fig(fig)

だいぶ見やすくなりました。 一方で、1970年代は2000年代と比較して半分以下のサンプルサイズしかないという情報は抜け落ちてしまいました。 形状を比較する分には問題ありませんが、各年代のサンプルサイズには偏りがあることは留意しておきましょう。

次は、バイオリンプロットと同様にバンド幅(bandwidth)を直接調整し、分布形状の粒度を微修正しましょう。 また、週刊マンガ雑誌の各話ページ数であることを考慮し、X軸の表示領域を現実的な範囲に絞りましょう。

Hide code cell source
# bandwidthを手動で再設定
fig.update_traces(bandwidth=0.5)

# 各話ページ数の表示範囲を再設定
fig.update_xaxes(range=[0, 50])

# 再表示
show_fig(fig)

マンガ雑誌の各話ページ数の推移が解釈しやすくなりました。 1970年代は20ページ付近にピークがありますが、徐々に19ページ以下が増え始め、2010年代には20ページ付近と18ページ付近の二つのピークを持つ分布になりました。

それでは、各マンガ雑誌の各話ページ数はどのように変遷したのでしょうか?

Hide code cell source
# リッジラインプロットを作成し、年代とマンガ雑誌名ごとにページ数を可視化
# データセットの半分をランダムにサンプリングし、データサイズを圧縮
# facet_colを使用してマンガ雑誌名ごとに分割し、facet_col_wrap=2で2列で表示
# height=600で図の高さを設定し、orientation="h"で縦横転置
# points=Falseでサンプルを表示しない設定
fig = px.violin(
    df_cm.sample(frac=0.5, random_state=RAND).sort_values(["年代", "マンガ雑誌名"]),
    y="年代",
    x="一話あたりのページ数",
    facet_col="マンガ雑誌名",
    facet_col_wrap=2,
    height=600,
    orientation="h",
    points=False,
)

# 各トレースの幅を2に設定、side="positive"で上部にのみ密度分布を表示
# bandwidth=0.5でバイオリンの幅を調整
fig.update_traces(width=2, side="positive", bandwidth=0.5)

# ファセット(マンガ雑誌名ごとのヒストグラム)のタイトルを簡潔にする処理
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))

# X軸とY軸の範囲を設定
fig.update_xaxes(range=[0, 50])
fig.update_yaxes(range=[0, 5])

# 作成した図を表示
show_fig(fig)

ヒストグラムでも触れたように、マンガ各話のページ数は誌面構成に大きな影響を与えます。 黎明期から、各誌が試行錯誤を重ねている様子が伝わってくるようで、(勝手に)感慨深いものを感じてしまいます。

アニメデータ#

アニメ作品の各話数の推移を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_ae = pd.read_csv(DIR_AN / FN_AE)
Hide code cell content
# 可視化用の集計

# df_aeにyears列を追加。unit_yearsは5年を指定
df_ae = add_years_to_df(df_ae, unit_years=5)

# 年代、アニメ作品ごとの合計各話数を集計し、n_ae列と命名
df_an = df_ae.groupby(["years", "acid"])["aeid"].nunique().reset_index(name="n_ae")

ここで、今回のグループ化変数である年代ごとのサンプルサイズを確認しておきましょう。

Hide code cell content
# 年代別のアニメ作品数を集計
df_an.groupby("years")["acid"].nunique().reset_index()
years acid
0 1960 2
1 1965 2
2 1970 3
3 1975 2
4 1980 1
5 1990 148
6 1995 336
7 2000 665
8 2005 1038
9 2010 1040
10 2015 755

1980年代まで、サンプルが非常に少ないことがわかります。 これでは正確な確率密度を計算できないため、可視化対象から外しましょう。

Hide code cell content
# ある程度のサンプル数を確保できる、1990年代以降に限定
df_an = df_an[df_an["years"].astype(int) >= 1990].reset_index(drop=True)

# 列名をわかりやすいものに変更
cols_an = {"years": "年代", "acid": "アニメ作品ID", "n_ae": "アニメ作品の合計話数"}
df_an = format_cols(df_an, cols_an)
Hide code cell content
# 可視化対象のDataFrameを確認
df_an.head()
年代 アニメ作品ID アニメ作品の合計話数
0 1990 C12701 99
1 1990 C12715 26
2 1990 C12729 14
3 1990 C15787 13
4 1990 C8319 49
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_an, DIR_OUT_AN, "an")
DataFrame is saved as '../../data/an/output/06/ridge/an.csv'.
Hide code cell source
# リッジラインプロットを作成し、年代ごとの合計話数を可視化
# orientation="h"で縦横転置し、points=Falseでサンプルを表示しない設定
fig = px.violin(
    df_an, y="年代", x="アニメ作品の合計話数", orientation="h", points=False
)

# 各トレースの幅を5に設定し、side="positive"で上部にのみ密度分布を表示
fig.update_traces(width=5, side="positive")

# X軸の範囲を0から500に設定
fig.update_xaxes(range=[0, 500])

# 作成した図を表示
show_fig(fig)

上図は、アニメ作品の各話数の分布を、年代別に表現したリッジラインプロットです。 1990年代から2015年代にかけて、徐々に分布が左側、つまり各話数が少ない方向に推移していくことがわかります。

もう少し詳細な分布の情報を得るため、bandwidthを調整してみましょう。 また、多くのアニメ作品が存在する100話までを表示するよう、設定を変更します。

Hide code cell source
# bandwidthを細かく調整することで、詳細な分布を表示
fig.update_traces(bandwidth=1)

# X軸の表示領域を100話までに変更
fig.update_xaxes(range=[0, 100])

# 再表示
show_fig(fig)

分布形状の情報が増え、どの年代も 1クール13話の整数倍付近にピークがある ことがわかりやすくなりました。

1990年代は39話(3クール)付近および52話(4クール)付近にピークがありましたが、徐々に13話(1クール)付近や26話(2クール)付近にピークが移動していることがわかります。 この可視化結果は、1997年に新世紀エヴァンゲリオンの深夜枠再放送が大成功を収めたことをきっかけの一つとして、それ以降1クールを中心とした深夜枠のアニメ作品が増加した[信之, 2022]という事実と矛盾しません。

ゲームデータ#

ゲームパッケージの価格の推移を可視化してみましょう。

Hide code cell content
# pandasのread_csv関数でCSVファイルの読み込み
df_pkg_pf = pd.read_csv(DIR_GM / FN_PKG_PF)
Hide code cell content
# add_years_to_df関数で5年区切りのyears列を追加
df_pkg_pf = add_years_to_df(df_pkg_pf, unit_years=5)

ここで、今回のグループ化変数であるyearsごとのサンプルサイズを確認しておきましょう。

Hide code cell content
# yearsごとのゲームパッケージ数を集計
df_pkg_pf.groupby("years")["pkgid"].nunique().reset_index()
years pkgid
0 1980 53
1 1985 453
2 1990 2074
3 1995 4590
4 2000 5587
5 2005 7637
6 2010 9344
7 2015 5900

1980年はサンプルサイズが小さいため、可視化対象から除外したほうが良さそうです。

Hide code cell content
# ある程度のサンプルサイズを確保できる、1985年代以降を分析対象とする
df_gm = df_pkg_pf[df_pkg_pf["years"].astype(int) >= 1985].reset_index(drop=True)

# すべてを描画すると重くなるので、0.5だけサンプリング
df_gm = df_gm.sample(frac=0.5, random_state=RAND).sort_values(
    "years", ignore_index=True
)

# 列名をわかりやすいものに変更
cols_gm = {
    "pfname": "プラットフォーム名",
    "pkgname": "パッケージ名",
    "price": "ゲームパッケージの価格",
    "years": "年代",
}
df_gm = format_cols(df_gm, cols_gm)
Hide code cell content
# 可視化対象のDataFrameを確認
df_gm.head()
プラットフォーム名 パッケージ名 ゲームパッケージの価格 年代
0 ファミリーコンピュータ SDガンダムワールド ガチャポン戦士2 カプセル戦記 6800.0 1985
1 ファミリーコンピュータ ラサール石井のチャイルズクエスト 5500.0 1985
2 SC-3000 倉庫番 4300.0 1985
3 ファミリーコンピュータ 未来戦史ライオス 6300.0 1985
4 ファミリーコンピュータ じゃじゃ丸 忍法帳 5800.0 1985
Hide code cell content
# 可視化対象DataFrameを保存
save_df_to_csv(df_gm, DIR_OUT_GM, "gm")
DataFrame is saved as '../../data/gm/output/06/ridge/gm.csv'.
Hide code cell source
# リッジラインプロットを作成し、年代ごとの価格を可視化
# orientation="h"で縦横転置し、points=Falseでサンプルを表示しない設定
fig = px.violin(df_gm, y="年代", x="ゲームパッケージの価格", orientation="h", points=False)

# 各トレースの幅を3に設定し、side="positive"で上部にのみ密度分布を表示
fig.update_traces(width=3, side="positive")

# 作成した図を表示
show_fig(fig)

上図は、ゲームパッケージの価格の分布の推移を表現したリッジラインプロットです。 年代別に特徴的な分布を持っているように見えますが、これだけでは何かを判断することはできません。

そこでバンド幅(bandwidth)を変更し、分布の詳細を確認しましょう。 また、update_xaxesrangeを変更し、価格のボリュームゾーンに絞って可視化を行いましょう。

Hide code cell source
# bandwidth=100でバイオリンの粒度を調整
fig.update_traces(bandwidth=100)

# X軸の表示範囲を0から10000に設定
fig.update_xaxes(range=[0, 10000])

# 更新した図を表示
show_fig(fig)

1985年付近ではおぼろげでしたが、1990年から2010年頃まで、1000円ごとに価格のピークが存在することがわかりやすくなりました。

1995年代(厳密には、1995年から1999年の間)に6000円付近のゲームパッケージが非常に多く発売されています。 1995年代に発売されたゲームパッケージについて詳しく見てみましょう。

Hide code cell content
# 1995年のデータを抽出し、プラットフォーム名ごとに価格の統計情報を集計
# describe()を使って、各プラットフォームの価格の要約統計量を計算
df_tmp = (
    df_gm[df_gm["年代"] == "1995"]
    .groupby("プラットフォーム名")["ゲームパッケージの価格"]
    .describe()
    .reset_index()
)

# 集計結果を価格のデータ数(count)で降順にソートし、上位5つを表示
# これにより、最も多くの販売データがあるプラットフォームを確認できる
df_tmp.sort_values("count", ascending=False).head()
プラットフォーム名 count mean std min 25% 50% 75% max
17 プレイステーション 1063.0 5453.513641 1838.891268 980.0 4800.00 5800.0 5800.0 29800.0
10 セガサターン 429.0 6296.783217 3119.043093 1280.0 5800.00 5800.0 6800.0 36800.0
9 スーパーファミコン 232.0 8908.435345 2702.894889 2000.0 7794.25 9800.0 10575.0 19800.0
8 ゲームボーイ 173.0 3715.445087 1457.178148 800.0 3000.00 3980.0 3980.0 14800.0
3 NINTENDO64 73.0 7064.383562 936.804537 4800.0 6800.00 6800.0 7500.0 9800.0

プレイステーションセガサターンスーパーファミコン、そしてNINTENDO64等の据置型ゲームプラットフォームが多数存在した時代だったことがわかりました。